Batch 4C pt1: HostConfig renderer fields + levels render-units (systemd cutover drop-ins) - #193
Conversation
levels render-units (systemd cutover drop-ins)
|
Update — increment 2 pushed ( Since you approved the
A real finding from building it: Each drop-in resets Tests: +15 (per-unit drop-in assertions + the CLI write/manifest/error paths). Full non-slow suite 1744 passed, ruff/format/mypy clean. Next (separate PR): increment 3 = |
Batch 4C foundation. docs/PLAN_4c_renderers.md lays out the remaining work to finish the paired-release cutover: render the systemd unit drop-ins / nginx vhosts / FPM open_basedir from host.yaml (replacing the hand-crafted runbook step 5), add the deployer's serving-path verification gate, and flip the live host to SERVING_CUTOVER=yes. It also records the verified consumer classification — note that #191's audit-gauges promotion makes it the 6th engine consumer to re-point (the runbook predates that and lists 5). This first increment adds only the scalar HostConfig fields the renderers need, all defaulting to the current WKCC shape (keep-current-then-flip, like the S3 slices) so nothing changes until the cutover host.yaml ships: - service_user / service_home — the account + home the units run as - release_root — the /opt/kayak paired-release root - fpm_pool_php — the PHP-FPM pool version in /etc/php/<v>/fpm Validators: service_user must be a bare POSIX username (it's interpolated into unit User= and shell ACL commands), the paths absolute, fpm_pool_php major.minor. docroot (existing) is reused — it flips to /var/cache/kayak/docroot at cutover. The vhost server_names type lands with the nginx renderer (increment 3) that consumes it, not here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…its`) Increment 2 of Batch 4C (docs/PLAN_4c_renderers.md): the systemd-unit renderer. `levels render-units` emits one `<unit>.service.d/cutover.conf` per engine consumer — the six units that run `levels …` from the venv — re-pointing each at `/opt/kayak/current/venv` and overriding the paths that move off the (now immutable) install root. Pure HostConfig→text (kayak.host_render), mirroring `emit-config`; `systemd-analyze verify` of the merged units happens on the VM. Each drop-in resets ExecStart (empty `ExecStart=` then the venv binary + the same subcommand/args) and ReadWritePaths (so the writable set exactly matches the cutover reality), pins `DATASET_DIR` to the release's dataset snapshot (not the live kayak_data clone), and sets `WorkingDirectory=/opt/kayak/current`. Surfaced while building it: `fetch-osmb` and `audit-gauges` default their generated-data dirs (map-layer staging, the gauge-metadata cache) *relative to the install root* (config.py BASE_DIR), which is read-only under a release — so their drop-ins must relocate those to `/var/cache/kayak/*`. Two new keep-current-then-flip HostConfig fields carry that: `map_layers_dir`, `gauge_metadata_cache` (defaults = the live repo-relative locations; the cutover host.yaml flips them, same as `docroot`). Only pipeline sets `OUTPUT_DIR` (the #3 shared docroot); status passes its `--output` arg (= `status_output`). Tests: every drop-in resets+repoints ExecStart at the venv and resets RWP, pins DATASET_DIR, and the per-unit specifics (pipeline docroot, fetch-osmb map-layers, audit-gauges cache + ${AUDIT_EMAIL}, status --output, DB-only units); plus the CLI's write/manifest/malformed-config paths. Full non-slow suite 1744 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1c608f8 to
1329ae1
Compare
Adversarial review — PR #193 (Batch 4C pt1: HostConfig renderer fields +
|
| unit | base ReadWritePaths |
rendered (default) | base args | rendered args |
|---|---|---|---|---|
| pipeline | public_html DB |
{docroot} DB |
pipeline |
pipeline ✓ |
| decimate | DB |
DB |
decimate |
decimate ✓ |
| editor-retention | DB |
DB |
editor-retention |
✓ |
| fetch-osmb | kayak/var/osmb |
{map_layers_dir} |
fetch-osmb |
✓ |
| status | /home/pat/var |
parent(status_output) |
status --output …/status.html |
✓ |
| audit-gauges | DB Gauge-metadata-cache |
DB parent(cache) |
audit-gauges --days 16 --email ${AUDIT_EMAIL} |
✓ |
The ReadWritePaths=/ExecStart= empty-reset is the correct drop-in idiom, the writable set flips coherently with its Environment= counterpart (docroot↔OUTPUT_DIR, map_layers↔MAP_LAYERS_DIR, cache↔GAUGE_METADATA_CACHE), and ${AUDIT_EMAIL}/--days 16/status_output are reproduced verbatim. That's the part most likely to silently break a sandbox at cutover, and it's correct.
service_user's validator genuinely blocks User=/shell-ACL injection; the CLI mirrors emit-config and a malformed host.yaml returns 1 (confirmed it propagates through main.py's sys.exit(rc)).
1. (Medium) The plan's "verified against systemd/" classification mis-describes two class-B units
§20's table is the source of truth the eventual KAYAK_UNITS/KAYAK_HOST_UNITS derivation (open decision D-CONSUMER) will consume, so its accuracy matters. Two rows are wrong against the live scripts:
kayak-recap— "run*.sh, NOT the venv" is false.systemd/kayak-recap.shruns:It does use the venv (the editable-install one). The "reads journald, not the DB" half is correct —"${KAYAK_HOME}/.venv/bin/python3" "${KAYAK_HOME}/kayak/scripts/recap.py" --days … --unit 'kayak-*'recap.pyshells tojournalctl, no DB. Net: the disposition (don't re-point) is low-impact since post-cutover it just renders a journald email from the old/home/pat/.venvtree — but it contradicts §Goal ("everylevels-running consumer must point atcurrent"), and the venv mis-statement is exactly what "verified againstsystemd/" should have caught.kayak-heartbeat— "curl heartbeat, not the DB" is wrong on both counts.systemd/kayak-heartbeat.shemails viamail/msmtp (nocurl) andstats${KAYAK_HOME}/DB/kayak.db(file mtime — not a content read). The disposition (no quiesce) still holds (astatdoesn't lock or read rows), but the description is inaccurate.
Recommend correcting the table so D-CONSUMER's list derivation rests on accurate facts: recap = "venv-python on a checkout script; journald only (no DB) → keep on checkout, no quiesce"; heartbeat = "mail heartbeat; stat()s the DB file (no content read) → no quiesce". Functionally the dispositions look right; the rationale just needs to match reality.
2. (Low) Path fields land in the same generated unit files as service_user, but skip its injection hardening
render_cutover_dropins interpolates the path fields by plain f-string — Environment=K={path}, ReadWritePaths={' '.join(paths)}, WorkingDirectory={path}, ExecStart={venv} … — yet _abs_path only checks startswith("/"). You deliberately tightened service_user because it's "interpolated into unit User= and shell ACL commands"; the paths land in those same files via the same mechanism with no such guard. A newline in a path field injects an arbitrary systemd directive into the drop-in (e.g. a second ExecStartPre=); a space splits a ReadWritePaths= entry. host.yaml is root-owned/trusted, so this is defense-in-depth rather than a live hole — but close the asymmetry by rejecting control chars/whitespace in the path validator. Two sub-nits while there: ^…$ lets "pat\n" pass _USERNAME_RE (Python $ matches before a trailing newline — use \A…\Z/fullmatch), and \d in _PHP_VERSION_RE matches Unicode digits (use [0-9]).
3. (Low, forward-looking) Rendered drop-ins are install-unsafe pre-cutover
Every drop-in references /opt/kayak/current (DATASET_DIR, WorkingDirectory, the ExecStart venv), which only exists after the first activation. Correct by design — render-units emits text and the cutover applies it — but a stray render-units --out-dir /etc/systemd/system on the pre-cutover host would point all six consumers' DATASET_DIR at a non-existent dir and break them. Worth a guard/warning when --out-dir targets a live systemd dir, or just a runbook note. (Also: --out-dir writes cutover.conf but never sweeps a stale one if the consumer set later shrinks — minor.)
Merge mechanics
Branch is 3 commits behind main and depends on #192's /var/cache/kayak/docroot (now merged) — that's the BLOCKED/not-up-to-date state. Rebase before merge; nothing in the diff conflicts.
Verdict
Approve in spirit — schema + renderer are clean, behavior-neutral, and the consumer/RWP/ExecStart parity (the easy-to-get-wrong part) is correct. Before this feeds the actual cutover, fix the recap/heartbeat rows in the classification table (#1), since that table is what the gate-list derivation will be built from.
Three review findings:
1. (Medium) The plan's "verified" consumer classification mis-described two
class-B units. Corrected against the live scripts: kayak-recap runs
`${KAYAK_HOME}/.venv/bin/python3 scripts/recap.py` — the EDITABLE-install venv
(not `levels`), journald only, no DB; kayak-heartbeat uses mail/msmtp (not
curl) and `stat()`s the DB file mtime (no content read). Dispositions
(recap/heartbeat not re-pointed, not quiesced) are unchanged — only the
rationale needed to match reality, which matters because this table is what
the D-CONSUMER list-derivation will consume. Added a note that recap isn't a
class-A unit (no `levels`, no DB/dataset) so §Goal doesn't reach it.
2. (Low) The path fields land in the same generated unit files as service_user
but skipped its injection hardening: paths are f-string-interpolated into
Environment=/ReadWritePaths=/WorkingDirectory=/ExecStart=, so a newline injects
a directive and a space splits a ReadWritePaths= entry. _abs_path now rejects
whitespace/control chars. Also fixed the regex sub-nits: `^…$` → `\A…\Z` on all
four validators (Python `$` matches before a trailing newline, so "pat\n" slid
through), and `\d` → `[0-9]` in the PHP-version regex (\d matches Unicode
digits). host.yaml is root-owned/trusted, so this is defense-in-depth.
3. (Low) render-units --out-dir now warns when {release_root}/current doesn't
exist yet — the drop-ins reference it, so installing them pre-cutover would
break the six consumers. Noted the (currently moot) stale-cutover.conf sweep
limitation inline.
Tests: +5 (trailing-newline username, Unicode-digit version, whitespace path,
the pre-cutover warning present/absent). Full non-slow suite 1749 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks — sharp review. All three addressed in 1 (Medium) — the misclassified rows. You're right; I verified against the live scripts and corrected the plan:
2 (Low) — the injection asymmetry. Closed it: 3 (Low) — install-unsafe pre-cutover. Tests: +5 (trailing-newline username, Unicode-digit version, whitespace path, the warning present/absent). Full non-slow suite 1749 passed; ruff/format/mypy clean. |
Batch 4C — part 1 of N: renderer foundation + the cutover plan
This kicks off the final Batch 4 slice: replacing the hand-crafted step 5 of the paired-release install runbook with renderers driven by
kayak.host.HostConfig, adding the deployer's serving-path verification, and flipping the live host toSERVING_CUTOVER=yes.Prereqs done: #190 (deployer), #191 (
levels audit-gauges), #192 (#3 docroot →/var/cache/kayak/docroot, merging today).docs/PLAN_4c_renderers.md— the roadmapLays out the remaining increments, the renderer mechanism (
levels render-*subcommands emitting text, per theemit-configprecedent), the per-consumer drop-in spec, and the VM-validation handoff. The notable finding it records: the consumer classification, verified againstsystemd/onmain—#191's audit-gauges promotion makes it the 6th engine consumer that must be re-pointed at the release venv, but the runbook §5c (written pre-#191) lists only 5. The plan also flags theKAYAK_UNITS(quiesce) vsKAYAK_HOST_UNITS(gate-exempt) split for the repo-shell-script DB-readers (healthcheck/config-drift).This PR — scalar
HostConfigfields onlyThe foundation the renderers consume, all defaulting to the current WKCC shape (keep-current-then-flip, mirroring the S3 site/region slices) so nothing changes until the cutover
host.yamlships:service_userpatUser=, ACL commands,KAYAK_APP_USERservice_home/home/patKAYAK_HOME, DB/var/log dirsrelease_root/opt/kayakfpm_pool_php8.4/etc/php/<v>/fpmpool pathdocroot(existing) is reused — it flips to/var/cache/kayak/docrootat cutover. Validators:service_usermust be a bare POSIX username (it's interpolated intoUser=and shell ACL commands — a; rm -rf /value is rejected), paths absolute,fpm_pool_phpmajor.minor. The vhostserver_namestype lands with the nginx renderer (increment 3) that consumes it, not here.Scope / why small
Increment 1 is deliberately schema-only and behavior-neutral — it lets the renderer mechanism + increment sequence (in the plan) get a review before I build the
render-*subcommands. Next:levels render-units(the 6 class-A drop-ins) with tests asserting they match the runbook spec.Tests
ruff/format/mypy clean; full non-slow suite 1729 passed (+4 new host-config tests: the 4 defaults, the override, and the username/path/version validators).
🤖 Generated with Claude Code